iT邦幫忙

2023 iThome 鐵人賽

DAY 30
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 30

【Elasticsearch】使用 Java API Client 完成簡易搜尋框架(下)+ 完賽小感言

  • 分享至 

  • xImage
  •  

昨天筆者設計了自定義的 REST API,期望透過藉由接收 qurey string,就能達到搜尋的效果。而該文文末提出的問題,其實都圍繞在「如何將 query string 轉化為 ES 的各種搜尋條件」。本文會設計「翻譯」的機制,用來完成這件「轉化」的工作。

本文內容與昨天一樣,偏向實際應用,不會介紹有關 Java API Client 新的知識點。


一、本文目標

(一)前情提要

讓我們回顧一下昨天文末提到的問題。假設 ES 的 document 結構如下。

{
    "id": "100",
    "name": "Vincent",
    "grade": 1,
    "bloodType": "A",
    "englishCertificate": {
        "type": "TOEIC",
        "issuedDate": "2023-01-01"
    }
}

並且呼叫 controller 的 REST API 進行搜尋時,攜帶了以下的 query string。

GET http://localhost:8080/students?grade=1&bloodType=A

grade=1bloodType=A 會被我們的程式,視為要搜尋「年級為 2」且「血型為 A」的學生。

(二)翻譯 Query String 的理由

請考慮以下的搜尋條件:

  • 年級(grade)落在「1 ~ 3」。
  • 英文檢定種類(englishCertificate.type)為「TOEIC」。
  • 血型(bloodType)未知,也就是欄位沒有值。

我們在昨天的文章規定,除了全文檢索、排序與分頁,其他自定義的 query string 都預設當作「相等條件」,因此以上的搜尋需求尚無法做到。

而本文為了解決此問題,將會設計「翻譯」的機制。舉例來說,假設攜帶以下的 query string。

GET http://localhost:8080/students
?gradeFrom=1
&gradeTo=3
&engCert=TOEIC
&bloodTypeExist=false

並且在 controller 用以下的類別來接收。

public class StudentRequestParameter extends RequestParameter {
    private Integer gradeFrom;
    private Integer gradeTo;
    private String engCert;
    private Boolean bloodTypeExist;

    // getter, setter ...
}

則我們期望這個翻譯機制,能夠將它們轉化為上面提到的各種搜尋條件。其實昨天的「全文檢索」本身也算是在做翻譯這件事。接下來的小節就開始實作吧!

二、數值範圍翻譯器

首先針對第一節提到的 grade 欄位,設計「數值範圍」的翻譯器。藉由這個範例,後續也能對翻譯器要撰寫什麼程式,有所依循。

public class NumberRangeTranslator {
    private final String docField;
    private final String gteKey;
    private final String lteKey;

    public NumberRangeTranslator(String gteKey, String lteKey, String docField) {
        // ...
    }

    public Set<String> getRequestParamKeys() {
        return Set.of(gteKey, lteKey);
    }

    public Query toEsQuery(Map<String, Object> requestParam) {
        var gteValue = (Integer) requestParam.get(gteKey);
        var lteValue = (Integer) requestParam.get(lteKey);

        return SearchUtils.createRangeQuery(docField, gteValue, lteValue);
    }
}

這個翻譯器類別從建構子接收了三個參數。gteKeylteKey 是 query string 中「大於等於」和「小於等於」的 key 名稱。docField 則是 document 的欄位名稱。

toEsQuery 方法,會接收承載 query string 的 Map(從 RequestParameter.getCustomizedParamMap 方法得來)。隨後根據自定義的邏輯,建立出代表 ES 搜尋條件的 Query 物件。以此為例,這裡呼叫了 Day 28 實作的 SearchUtils.createRangeQuery 方法,建立出範圍條件

最後是 getRequestParamKeys 方法,它會在整合到 AbstractEsRepository 時,控制要將哪些 query string 傳遞給翻譯器的 toEsQuery 方法。到了第五節會比較清楚。

三、欄位對應翻譯器

本節將針對第一節提到的 englishCertificate.type 欄位,設計「欄位對應」的翻譯器。它適合應用在 document 欄位名與 query string 的 key 不同的情形。比方說想用簡短的 key 名稱,去搜尋名稱很長,或階層深入的 document 欄位。

public class KeyMappingTranslator {
    private final String paramKey;
    private final String docField;

    public KeyMappingTranslator(String paramKey, String docField) {
        // ...
    }

    public String getRequestParamKey() {
        return paramKey;
    }

    public Query toEsQuery(Map<String, Object> requestParam) {
        var value = requestParam.get(paramKey);
        return SearchUtils.createTermQuery(docField, value);
    }
}

這個翻譯器類別從建構子接收了兩個參數,其中 paramKey 是 query string 的 key。而 toEsQuery 方法,會建立出代表相等條件的 TermQuery 物件後回傳。

四、設計翻譯器介面

為了在第五節將各種翻譯器整合到 AbstractEsRepository,以達到泛用,我們需要設計一個介面。

正如同先前為了將 StudentRequestParameter 整合到 AbstractEsRepository,於是繼承了 RequestParameter,甚至還提供方法轉換成 Map。

觀察前面兩個翻譯器的例子,它們都具有兩個方法:

  • 取得相關 query string 的 key
  • 將 query string 轉化為 Query 物件

為此,以下的翻譯器介面,提供了這兩個方法。

public interface ParameterTranslator {
    Set<String> getRequestParamKeys();
    Query toEsQuery(Map<String, Object> requestParam);
}

接著讓第二、三節的 NumberRangeTranslatorKeyMappingTranslator 翻譯器去實作。

public class NumberRangeTranslator implements ParameterTranslator {
    // ...

    @Override
    public Set<String> getRequestParamKeys() {
        return Set.of(gteKey, lteKey);
    }

    @Override
    public Query toEsQuery(Map<String, Object> requestParam) {
        // ...
    }
}

public class KeyMappingTranslator implements ParameterTranslator {
    // ...

    @Override
    public Set<String> getRequestParamKeys() {
        return Set.of(paramKey);
    }

    @Override
    public Query toEsQuery(Map<String, Object> requestParam) {
        // ...
    }
}

五、將翻譯機制整合到 Repository 層

(一)搜尋程式概觀

回顧一下昨天在 AbstractEsRepository 實作到一半的搜尋程式。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 排序、分頁、全文檢索...

        // TODO: 翻譯

        // 其餘視為相等條件
        assignEqualCondition(requestParamMap, queryBuilder);

        // ...
    }
}

目前已經對全文檢索、排序與分頁相關的 query string 做處理。而本文實作的翻譯器,所用到的 query string 不能直接被當作相等條件,因此要在 assignEqualCondition 方法之前,進行翻譯的工作。

(二)提供翻譯器

為了得知每一種 document 需要做什麼樣的翻譯,我們在 AbstractEsRepository 宣告抽象方法來取得翻譯器,並讓子類別實作。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...
    protected abstract Set<ParameterTranslator> getParameterTranslators();
}

以下是子類別 StudentEsRepository 提供的翻譯器。

public class StudentEsRepository extends AbstractEsRepository<Student> {
    // ...

    @Override
    protected Set<ParameterTranslator> getParameterTranslators() {
        return Set.of(
                new NumberRangeTranslator("gradeFrom", "gradeTo", "grade"),
                new KeyMappingTranslator("engCert", "englishCertificate.type.keyword")
        );
    }
}

以上的翻譯需求參考自第一節。gradeFromgradeTo 這兩個 query string,會用來建立出範圍條件,搜尋 document 的 grade 欄位。而 engCert 則用來搜尋名稱較長的深層欄位。

(三)實作翻譯過程

以下宣告一個叫做 assignTranslatedCondition 的方法,它會將 query string 傳遞給翻譯器來處理。取得翻譯結果的 Query 物件後,附加到 BoolQuery 上。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...
    protected abstract Set<ParameterTranslator> getParameterTranslators();

    private void assignTranslatedCondition(Map<String, Object> paramMap, BoolQuery.Builder builder) {
        Set<ParameterTranslator> translators = getParameterTranslators();
        translators.forEach(translator -> {
            var subParam = new HashMap<String, Object>();
            translator.getRequestParamKeys().forEach(key -> {
                var value = paramMap.remove(key);
                if (value != null) {
                    subParam.put(key,value);
                }
            });

            if (!subParam.isEmpty()) {
                var query = translator.toEsQuery(subParam);
                builder.filter(query);
            }
        });
    }
}

程式流程中,會先取得子類別(如 StudentEsRepository)定義好的翻譯器。再讓每個翻譯器輪流從 query string 中取出自己需要的值,組成「子 Map」(subParam)。接著將子 Map 傳入翻譯器的 toEsQuery 方法取得結果。

此處取出 query string 時,使用了 Map.remove 方法。這是因為如果不把值從 Map 中移除,它們後續會被當作相等條件,意外地由 assignEqualCondition 方法處理。

最後將這個 assignTranslatedCondition 方法,加入到搜尋程式中,就能支援翻譯了!

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 排序、分頁、全文檢索 ...

        // 翻譯
        assignTranslatedCondition(requestParamMap, queryBuilder);

        // 其餘視為相等條件
        assignEqualCondition(requestParamMap, queryBuilder);

        // ...
    }
}

六、自定義 Document 欄位

若在 document 的 model 類別宣告 getter 方法,那麼在儲存到 ES 時,library 會自動在 document 添加該欄位。本節來看看這項特色可以有怎樣的應用。

(一)Model 類別

回顧這四天 Elasticsearch 小系列文章的各種範例,首先整理一下目前 document 的 model 類別,也就是 Student,有哪些欄位。

public class Student implements EsDocument {
    private String id;
    private String name;
    private int grade;
    private LocalDate birthday;
    private String bloodType;
    private String introduction;
    private EnglishCertificate englishCertificate;
    private Set<Course> courses = Set.of();
    
    public int getTotalCoursePoint() {
        return courses.stream()
                .mapToInt(Course::getPoint)
                .sum();
    }

    // getter, setter ...
}

public class EnglishCertificate {
    private String type;
    private LocalDate issuedDate;

    // getter, setter ...
}

public class Course {
    private String name;
    private int point;

    // getter, setter ...
}

(二)應用場景

筆者特地在前面的 Student 類別,宣告了叫做 getTotalCoursePoint 的方法,目的是計算「修習課程的總學分」。如此一來,document 便會多出 totalCoursePoint 欄位,如下。

{
    "id": "100",
    "courses": [
        { "name": "計算機概論", "point": 3 },
        { "name": "程式設計", "point": 4 }
    ]
    "totalCoursePoint": 7,
    "...": "..."
}

以此為例,有了新欄位,若想依據修習課程的總學分來排序學生,就能使用以下的 query string 了。

GET http://localhost:8080/students?sortFields=totalCoursePoint&sortOrders=desc

添加的自定義欄位,不僅能用來排序,還可用在搜尋,是實用的小技巧。

當然也可以設計一套機制,來翻譯排序相關的 query string。但受限於鐵人賽時程關係,筆者就不進行程式實作。

Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。


七、完賽小感言

這次的鐵人賽系列文章到此結束!由於是在待業期間參加鐵人賽,所以有餘力將大多數文章都寫得蠻長的。過程中除了複習前公司有用過,但印象逐漸模糊的東西,也學習大大小小的新知識。

筆者比較有成就感的文章,是 Day 6 ~ 7 的「HashMap 原理」。以及 Day 27 ~ 30 的「Elasticsearch」,雖然沒有實作出 function score,有點美中不足就是了(但還是可以另外寫啦)。另外也將 Day 22 ~ 26 的「Spring Security」重新梳理一次,畢竟這是部落格中觀看數偏高的,更要好好維護。

筆者後續的個人計畫,會將這幾個小系列的完成後程式專案,做適度的優化後,上傳到 Github。接著將這次鐵人賽的內容,轉載至個人部落格,或是翻新以前寫過的文章。它們都可以成為我履歷的一部份。

完成之後,應該會繼續學習對找工作有幫助的東西吧!
希望明年的這個時候,我已經在新公司穩定下來了/images/emoticon/emoticon47.gif


上一篇
【Elasticsearch】使用 Java API Client 完成簡易搜尋框架(上)
下一篇
【Docker】基本介紹與安裝 Docker Desktop
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言